feat(datagrid): type expression index keys and read them back on SQLite, MySQL and DuckDB - #3080
Merged
Merged
Conversation
…currently, and gate its structure edits by kind
…s stay out of dumps and copies
…te, MySQL and DuckDB
datlechin
changed the base branch from
fix/postgres-invalid-index-ddl
to
main
September 23, 2026 19:21
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Automations to automatically generate PRs for you. |
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #3075
Summary
lower(email)orcoalesce(a, b), is now an expression key on PostgreSQL, PGlite, SQLite, libSQL, Turso, Cloudflare D1, DuckDB and MySQL 8.0.13+. Before, the cell split it at every comma and read the pieces as column names, so the row was flagged and nothing could be saved.DESCkey parts survive a rename.ALTER TABLE t DROP INDEX i, ADD ..., so a replacement the server rejects no longer loses the original.Root cause
Authoring:
StructureEditingSupport.updateIndexcounted an entry as an expression only if the index already held it, and split everything else at every comma. It had neither the table's columns nor the engine's grammar to telllower(v)from a name.Round trip: only PostgreSQL read expressions. SQLite, libSQL and D1 joined
pragma_index_info, whose name is NULL for an expression part (cid -2), and dropped it. MySQL droppedSHOW INDEXrows whoseColumn_nameis NULL. DuckDB's regex split read(COALESCE(a, b))as a column. Every one of those writers quoted each key part as an identifier, and none readDESC(MySQL) or theWHEREof a partial index (SQLite family).Atomicity:
SchemaStatementGeneratoralways split a modified index into aDROPand anADD. MySQL DDL is not transactional, so a failingADDleft the table without the index.What changed
IndexKeyDialect(app, curated per database type likeForeignKeyDialect):takesPrefixLengthsandtakesExpressions. Unknown types get columns only.IndexKeyList(app, pure): splits the cell at depth-0 commas withSQLTokenCursorover the connection's execution grammar (SQLLexicalResolver.executionGrammar), so MySQL backslash strings, PostgreSQLE''and dollar quotes, SQLite brackets and a session'sNO_BACKSLASH_ESCAPESare all read the engine's way. A known column name (commas and apostrophes included) or an existing expression is matched whole first. Then each entry is classified: existing expression, known column (any case, with or without one pair of parentheses), quoted identifier,name(N)prefix where the engine takes prefixes, bare identifier, then balanced plain code as an expression where the engine takes them. A trailingASC/DESC/NULLS FIRST|LAST, a comment, an unterminated literal or unbalanced text is left as a column so the missing-column check names it before Save.SQLTokenCursorgained alocationaccessor.StructureEditingSupport.updateIndex(_:at:with:keys:)takes anIndexKeyContextfrom both grid delegates (the inspector routes through them).indexModifiedIndicesalso tints Columns when expressions change.EditableIndexDefinition's catalog key shape now includescolumnPrefixes, so a changed MySQL prefix retires the server's spelling.Info.plistedits):SQLIndexKeyList: reads a storedCREATE INDEXinto its key list, key parts and predicate, strips a trailing sort order, unwraps one pair of parentheses, and reads a quoted identifier, all on the kit'sSQLFeatureLexer.SQLiteIndexCatalog: one per-table and one schema-wide query (pragma_index_list+pragma_index_xinfo ... key = 1+sqlite_master.sql), grouping intoPluginIndexInfowith expressions,whereClauseand the verbatim key list asddlMethodAndKeys, plus the writer (createStatement,keyList). SQLite, libSQL and D1 all use it; their three copies of the read and writers are gone.PluginDatabaseDriver.generateModifyIndexSQL(table:oldIndexName:newIndex:), defaulting to nil.SchemaStatementGeneratorkeeps a modified index whole when the driver answers that hook and the save changes no column; otherwise it splits as before (the halves belong on opposite sides of column work).MySQLIndexRowcarries a key part (column with prefix, or expression) and whether it is descending. The reads takeExpressionfromSHOW INDEXby column name andSTATISTICS.EXPRESSIONonly on MySQL 8.0.13+ (NULL elsewhere, so MariaDB never errors), with one level of backslash escaping removed. Grouping fillsexpressionsand, when a part isDESC, the key spelling. The writer puts expressions in parentheses.schemaOperationRefusal(.addIndex)refuses expression keys on MySQL below 8.0.13 and on MariaDB.generateModifyIndexSQLanswers for MySQL and MariaDB only.DuckDBIndexClausesreadsduckdb_indexes().sqlthroughSQLIndexKeyList(a parenthesized part is an expression, a quoted name is unquoted) and writes expressions as(expr).extractIndexColumnsand its regex are gone.features/table-structure.mdx(an Expression keys section replaces "An expression typed here is read as a column name") anddatabases/mysql.mdx(8.0.13 and MariaDB limitation). CHANGELOG: one Added, four Fixed.Measured
CREATE INDEX "ix" ON ... USING btree ("tenant_id", (lower(email))),((a || ', ' || b), "id")and a multi-line(CASE ... END)all ran;((lower(email) DESC))issyntax error at or near "DESC".SHOW INDEXandSTATISTICSreportColumn_nameNULL withExpression lower(`v`)andCollation Dfor((lower(v)) DESC).STATISTICS.EXPRESSIONadds one backslash level (_utf8mb4\'it\\\'s\'); removing it gives theSHOW CREATE TABLEform, and replaying that created an identical index. Every statement the writer tests expect ran:ALTER TABLE `t` DROP INDEX `i_fn`, ADD INDEX `i_fn_lower` ((lower(`v`)) DESC) USING BTREEre-read withCollation D,(`v` DESC, `id`)likewise, and(`id`, (coalesce(a, b)), `email`(20))kept its prefix. A combined replace whoseADDfails (Unknown column 'zz' in 'functional index', and aDESCinside the parentheses, 1064) left the index in place, where the split form lost it.DROP INDEX, ADD INDEXworks;ADD INDEX i ((lower(v)))is 1064;STATISTICShas noEXPRESSIONcolumn andSHOW INDEXhas none either.pragma_index_xinforeports cid -2 with a NULL name for expression parts anddesc/collper part;sqlite_master.sqlkeeps the statement verbatim (((lower(v)))stays doubled). The catalog query was run live in the new tests against the system SQLite.sqlstores every expression key wrapped in one more pair of parentheses (lower(v)is stored as the key(lower(v)),(a || ', ' || b)as(((a || ', ') || b))), stores(v)as the columnv, acceptsv DESCbut drops theDESC, rejects(lower(v) DESC), and has no partial indexes.Tests
Run through
verify.shon this machine, every suite confirmed executed:IndexKeyListTests16,StructureEditingSupportIndexKeyTests9,StructureChangeManagerIndexExpressionTests5,StructureEditingSupportFieldDiffTests13,StructureEditingSupportBooleanParsingTests60,StructureIndexTypeMenuTests4,IndexDefinitionCatalogSpellingTests12,SchemaStatementGeneratorPluginTests25,MySQLIndexGroupingTests7,MySQLIndexKeyWriterTests6,MySQLFunctionalKeyPartsTests6,MySQLCreateTableTests10,SQLiteIndexCatalogTests8,SQLiteCreateTableDDLTests9,DuckDBIndexClausesTests5,SQLIndexKeyListTests6,SQLTokenCursorTests16.EditableIndexDefinition,SchemaStatementGenerator, the grid delegates,SQLTokenCursor), 248 cases, all passed:StructureGridDelegateInspectorTests,StructureGridDelegateAddRowTests,CheckConstraintStatementTests,SchemaOperationRefusalTests,ClickHouseIndexEditTests,StructureChangeManagerClusteredIndexTests,StructureChangeManagerCatalogSpellingTests,CrossEngineIndexExpressionTests,CrossEngineIndexTypeTests,CrossEngineKeyTranslationTests,SchemaSyncScriptBuilderTests,StructureDiffEngineTests,TableStructureIndexReplayTests,CreateTableDraftBuilderIndexExpressionTests,CreateTableDraftBuilderTests,IndexDefinitionTests,IndexDefinitionPasteTests,IndexTypeTests,SchemaChangeTests,StructureChangeGuardTests,SQLSetAssignmentsTests,PluginIndexMappingCoverageTests,StructureRowProviderTests.TablePro,SQLiteDriver,LibSQLDriverPlugin,CloudflareD1DriverPlugin,MySQLDriver,DuckDBDriver,PostgreSQLDriver.check-pluginkit-abi.shagainst the merge base: additions only (generateModifyIndexSQLwith its default,SQLIndexKeyList,SQLiteIndexCatalog); the one changed line is fix(plugin-postgresql): follow pg_dump's index rule so invalid indexes stay out of dumps and copies #3075's@_disfavoredOverloadon an unchangedPluginIndexInfoinit. Kit 33 is already pending, so no bump and noInfo.plistedits.--stricton all 41 changed Swift files: two violations, both on untouched older lines (SQLiteCreateTableDDLTests.swift:8import order,SchemaStatementGeneratorPluginTests.swift:543unused closure parameter). Docs checks pass.What turns each red without this change:
IndexKeyListTests,StructureEditingSupportIndexKeyTests.typedExpressionIsAnExpression,StructureChangeManagerIndexExpressionTests.typedExpressionOnANewIndex: the oldupdateIndexsplitscoalesce(a, c)intocoalesce(aandc), and the change manager reportsIndex references a column that does not exist: lower(email).SchemaStatementGeneratorPluginTests.modifyIndexInOneStatement: remove thekeepsIndexModifiesWholearm insortByDependencyand the modify is split intoDROP+CREATE.MySQLIndexGroupingTests.functionalKeyPartsAreRead/catalogEscapingIsRemoved/descendingKeysAreSpelled,MySQLIndexKeyWriterTests.renameKeeps*: the oldMySQLIndexRowrequired a column (the rows were dropped) and carried no collation, so the rename wrote noDESC.MySQLFunctionalKeyPartsTests: new gate; returning nil fromrefusalfails the MySQL 8.0.12 and MariaDB cases.SQLiteIndexCatalogTests: the oldpragma_index_inforead givesi_mixas[id],i_fnas[]and no condition; the old writer quotesa || ', ' || bas an identifier.DuckDBIndexClausesTests: the old regex split read(COALESCE(a, b))as a column and quoted it on write.IndexDefinitionCatalogSpellingTests.prefixEditRetiresTheKeySpelling: withoutcolumnPrefixesin the key shape a changed prefix keeps writing the old spelling.StructureEditingSupportFieldDiffTests.indexExpressionsChanged: without theexpressionscomparison nothing is tinted.Not added: the design's
StructureIndexExpressionUITests. The flow is deterministic, but lanes run in parallel on one shared screen, so an XCUITest could not be run here, and an unrun UI test would put a red gate on CI. The unit suites cover cell text to staged change to SQL, andSQLiteIndexCatalogTestsruns the write and the re-read against real SQLite.Before / After
Screenshots to be added. States to capture, on the sample SQLite database and a PostgreSQL table:
lower(name): before, the row is flaggedIndex references a column that does not exist: lower(name); after, no flag and Save enabled.lower(name)in Columns.WHEREpredicate.KEY i_fn ((lower(v)) DESC): before, noi_fnrow at all; after, a row readinglower(`v`).Critique points not taken
CockroachPluginDriver.fetchIndexes, because another lane is changing that driver for materialized views; the read fix is listed below.IndexKeyDialectkeeps exactlytakesPrefixLengthsandtakesExpressions. The typed sort-order refusal is uniform across engines instead of a third flag: the grid model has no per-key sort order, so a typedDESCis refused everywhere, and an existing one is kept through the server's spelling.SHOW INDEX, and refusing would block renaming one. Typed expressions are still not offered on those types, and the one-statement modify is not used for them (TiDB before 6.2 rejects multi-changeALTER TABLE), so their modify stays as before.Deliberately not fixed here
SHOW INDEXESreports an expression part as the hidden columncrdb_internal_idx_exprand keepsstoringrows as key parts:Plugins/PostgreSQLDriverPlugin/CockroachPluginDriver.swift:115. Readdefinitionfor those rows and skipstoring = t, then add CockroachDB toIndexKeyDialect.pragma_index_infoand drops expression parts:TableProMobile/TableProMobile/Drivers/SQLiteDriver.swift:186. It can callSQLiteIndexCatalogdirectly.SHOW INDEXrows with a NULLColumn_name:TableProMobile/TableProMobile/Drivers/MySQLDriver.swift:212.STATISTICS.EXPRESSIONis only selected on MySQL 8.0.13+, so the schema-wide read (Compare) on those servers still drops functional key parts:Plugins/MySQLDriverPlugin/MySQLPluginDriver+BulkMetadata.swift:44. Needs a measured gate per engine.NO_BACKSLASH_ESCAPESa MySQL expression read from the catalog is written back in its backslash-escaped form, which that session reads differently. Rare, and the same for every other catalog text the MySQL writer replays.pragma_index_xinfoandsqlite_masterjoin SQLite runs, and D1 already readssqlite_masterfor triggers. libSQL and D1 are registry plugins and need a re-release against kit 33.